Skip to content

fix(security): stop a project's CSRF policy blocking its own channel dispatch - #3647

Merged
kojiwakayama merged 2 commits into
mainfrom
fix/csrf-channels-invoke
Aug 12, 2026
Merged

fix(security): stop a project's CSRF policy blocking its own channel dispatch#3647
kojiwakayama merged 2 commits into
mainfrom
fix/csrf-channels-invoke

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

The hole

PR #3641 fixed the control-plane routes. The same class of failure survives on a different path: POST /channels/invoke.

A Slack or Discord message reaches an agent because the platform channel dispatcher POSTs /channels/invoke carrying a signed dispatch envelope in x-veryfront-dispatch-jws, and resolveRuntimeOwnerInvokeUrl (src/internal-agents/runtime-owner.ts) re-dispatches to the same route when another runtime instance owns the run. Neither caller is a browser, so neither holds a __Host-vf_csrf cookie to echo.

CsrfHandler runs at priority 5 with patterns: []; ChannelInvokeHandler runs at 700. So any project that set security.csrf to anything truthy answered its own channel dispatch with 403 Forbidden - invalid or missing CSRF token. The agent never ran and the channel just went quiet -- nothing in the failure names CSRF or config.

Scoping: is this a control-plane surface?

No, and it must not be added to isControlPlaneSurfaceRoute.

CONTROL_PLANE_SURFACES = ["studio","channels","a2a","mcp"] is the surface claim inside a control-plane envelope -- it names which product surface an agents-list request is made for. It is not a route table, and its "channels" entry is not the /channels/invoke HTTP route.

The route carries a different envelope entirely:

control-plane surfaces /channels/invoke
header x-veryfront-control-plane-jws x-veryfront-dispatch-jws
verifier verifyControlPlaneJws verifyDispatchJws
claims ControlPlaneClaims (surface, request_method, request_path, request_hash) DispatchClaims (platform, sub=dispatchId, body_sha256)

Extending the existing shape list would have been wrong twice over: the caller does not present the control-plane header, so the new entry would do nothing; and making it do something would mean loosening the header check that is exactly what keeps isSignedControlPlaneDispatch narrow.

The proxy layer already models this distinction -- classifyInternalControlPlaneRequest returns "dispatch", not "control-plane", for this route, and src/proxy/control-plane-signature.test.ts already asserts "does not accept a control-plane JWS on /channels/invoke".

The fix -- same shape as #3641, second mechanism justified

isSignedChannelDispatch(req) returns true only when both:

  1. isChannelDispatchRoute matches -- exactly POST /channels/invoke, matched against URL.pathname (which resolves dot segments), never by prefix, because /channels/ is reserved but not exclusively routed; and
  2. the request carries x-veryfront-dispatch-jws.

It is not a weakening: it exempts nothing that is not authenticated more strongly downstream. verifyDispatchJws binds the Ed25519 signature to issuer, project audience, project id, dispatch id, platform and a SHA-256 hash of the body, with expiry and skew bounds; readSignedChannelDispatchRequest then rejects any envelope whose claims disagree with the payload it acts on.

The proxy classifier drops its second copy of the same route shape and consumes isChannelDispatchRoute, the way #3641 folded the control-plane table together.

Tests

RED first. src/channels/invoke-dispatch-security.test.ts drives the real chain -- RouteRegistry with CsrfHandler then ChannelInvokeHandler, a real Ed25519-signed envelope -- and failed for the right reason before the fix:

answers a dispatch when the project enables csrf with a boolean ... FAILED
error: AssertionError: Values are not equal: channel dispatch never reached the
agent; runtime answered 403: Forbidden – invalid or missing CSRF token
    [Diff] Actual / Expected
-   false
+   true

The three adversarial cases in that file passed before and after.

Adversarial coverage (the gate must still reject). 403 is asserted for:

  • an invoke POST with no signature header
  • an invoke POST carrying only a control-plane signature (wrong envelope)
  • a dispatch signature on a control-plane surface (wrong envelope, other direction)
  • an empty signature header
  • an invoke POST that takes the exemption but whose envelope does not verify (not a compact JWS, and a well-formed envelope signed by an untrusted key) -- full chain, asserts 401 and the agent never reached
  • a genuinely signed envelope aimed at a look-alike or sibling route: /channels/invoke/application-route, /channels/invoker, /channels/invoke-mirror/run, /channels, /api/channels/invoke, and PUT / DELETE on the real path

No existing test was weakened or deleted. docs/api-reference/veryfront/security.md is a regenerated line pin (deno task docs), not a hand edit.

Verification

Deno 2.7.7. deno check, deno fmt:check, deno lint, lint:module-boundaries, lint:dependency-boundaries, lint:barrel-jsdoc, docs:api-reference:check all clean.

deno test src/proxy/ src/channels/ src/security/ src/server/handlers/request/ -> 199 passed / 1 failed. The one failure is agent-stream.handler.test.ts, confirmed pre-existing: an identical run on a detached origin/main worktree produces a byte-identical failing-step set (diff -> IDENTICAL FAILING SET).

Summary by CodeRabbit

  • New Features

    • Signed Slack and Discord channel dispatch requests can now pass through CSRF protection when properly verified.
    • Channel dispatch routing now distinguishes valid POST /channels/invoke requests from similar or unsupported routes.
  • Bug Fixes

    • Prevented unsigned, mismatched, or incorrectly routed dispatch requests from bypassing CSRF validation.
  • Documentation

    • Corrected the CsrfHandler source reference in the security API documentation.

…dispatch

PR #3641 fixed the control-plane routes. The same class of failure survives on
`POST /channels/invoke`.

A Slack or Discord message reaches an agent because the platform channel
dispatcher POSTs `/channels/invoke` carrying a signed dispatch envelope in
`x-veryfront-dispatch-jws`, and `resolveRuntimeOwnerInvokeUrl` re-dispatches to
the same route when another runtime instance owns the run. Neither caller is a
browser, so neither holds a `__Host-vf_csrf` cookie to echo. `CsrfHandler` runs
at priority 5 with an empty pattern list, ahead of `ChannelInvokeHandler` at
700, so any project that set `security.csrf` to anything truthy answered its own
channel dispatch with `403 Forbidden - invalid or missing CSRF token`. The agent
never ran and the channel just went quiet: nothing in the failure names CSRF or
config.

Scoping. `/channels/invoke` is NOT a control-plane surface and must not be added
to `isControlPlaneSurfaceRoute`. The `CONTROL_PLANE_SURFACES` enumeration
`["studio","channels","a2a","mcp"]` is the `surface` claim inside a
control-plane envelope -- it names which product surface an agents-list request
is made for, not this HTTP route. The route carries a different envelope
entirely: `verifyDispatchJws` against `DispatchClaims`, not `verifyControlPlaneJws`
against `ControlPlaneClaims`, under a different header. Extending the existing
shape list would therefore have been wrong twice over: the caller does not
present the control-plane header, so the entry would do nothing, and making it
do something would mean loosening the header check that makes
`isSignedControlPlaneDispatch` narrow.

So it gets its own positively-identified, proof-carrying predicate rather than a
path exemption. `isSignedChannelDispatch` returns true only when BOTH the
request is exactly `POST /channels/invoke` -- matched against `URL.pathname`,
which resolves dot segments, and never by prefix, because `/channels/` is
reserved but not exclusively routed -- AND it carries
`x-veryfront-dispatch-jws`. `verifyDispatchJws` then binds the Ed25519 signature
to the issuer, project audience, project id, dispatch id, platform and a
SHA-256 hash of the body with expiry and skew bounds, and the handler rejects an
envelope whose claims disagree with the payload it acts on. The exemption
therefore never covers anything that is not authenticated more strongly
downstream.

The proxy classifier drops its second copy of the same route shape and consumes
`isChannelDispatchRoute`, the way #3641 folded the control-plane table together.

Covered by a dispatch test that drives the real chain -- `CsrfHandler` then
`ChannelInvokeHandler`, with a real Ed25519-signed envelope -- across the csrf
shapes, and asserts the agent is reached. The adversarial cases assert the gate
still returns 403 for: an invoke POST with no signature, an invoke POST carrying
only a control-plane signature, a dispatch signature on a control-plane surface,
an empty signature header, and a genuinely signed envelope aimed at
`/channels/invoke/application-route`, `/channels/invoker`,
`/channels/invoke-mirror/run`, `/channels`, `/api/channels/invoke`, `PUT` and
`DELETE`.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

/channels/invoke now has dedicated signed-dispatch classification. CsrfHandler bypasses CSRF for valid signed channel dispatches. Tests cover accepted signatures, route boundaries, missing signatures, and unsupported methods.

Changes

Channel dispatch security

Layer / File(s) Summary
Channel route and signature classification
src/channels/control-plane.ts, src/proxy/control-plane-signature.ts
Adds canonical channel constants and strict predicates for signed POST /channels/invoke requests.
CSRF bypass integration
src/security/http/csrf/csrf-handler.ts, docs/api-reference/veryfront/security.md
Exempts signed channel dispatches from CSRF validation and updates the CsrfHandler source reference.
Dispatch security regression coverage
src/channels/invoke-dispatch-security.test.ts, src/security/http/csrf/csrf-handler.test.ts
Tests valid signed dispatches, signature separation, exact route matching, unsupported methods, and empty or missing signatures.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Request
  participant CsrfHandler
  participant ChannelInvokeHandler
  participant Agent
  Request->>CsrfHandler: POST /channels/invoke with dispatch JWS
  CsrfHandler->>CsrfHandler: classify signed channel dispatch
  CsrfHandler->>ChannelInvokeHandler: bypass CSRF validation
  ChannelInvokeHandler->>Agent: dispatch channel request
Loading

Possibly related PRs

Suggested reviewers: kwakayama, ariskemper

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main security fix for CSRF blocking project channel dispatch.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/csrf-channels-invoke

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/channels/invoke-dispatch-security.test.ts`:
- Around line 245-263: Add an integration test alongside the existing invoke
POST rejection cases that passes a malformed x-veryfront-dispatch-jws value
through dispatchChannelInvoke, exercising CsrfHandler and RouteRegistry. Assert
that the full request chain returns status 401 and answered === false.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f21eace7-d3d6-418b-937f-819de0f86f63

📥 Commits

Reviewing files that changed from the base of the PR and between 870bcde and 02b4aca.

📒 Files selected for processing (6)
  • docs/api-reference/veryfront/security.md
  • src/channels/control-plane.ts
  • src/channels/invoke-dispatch-security.test.ts
  • src/proxy/control-plane-signature.ts
  • src/security/http/csrf/csrf-handler.test.ts
  • src/security/http/csrf/csrf-handler.ts

Comment thread src/channels/invoke-dispatch-security.test.ts
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Two follow-ups from reading this alongside #3641, #3645 and #3646

Both are handled in #3649, which stacks on this branch — nothing needs to change here.

1. The exemption is only one gate out of three. On the merged tree, a channel dispatch carrying its envelope was still 401'd by a project's security.auth and still turned away by a gating root middleware.ts. The caller this PR set out to unblock was still blocked twice, so a project's Slack and Discord agents would have gone quiet anyway. #3649 applies isSignedChannelDispatch at those two sites and adds a {dispatch kind} × {gate} matrix test so the next gate cannot land with a partial exemption. The two predicate families stay separate, as you argued — what gets shared is the set of gates, not the predicate.

2. The docstring inherited from #3641 is unsound. isSignedChannelDispatch repeats verbatim:

A browser cannot attach the signature header to a cross-origin request without a preflight the runtime does not grant.

The runtime grants it. With no configured allowedHeaders, resolveNormalizedCORSPreflightPolicy reflects whatever Access-Control-Request-Headers asked for:

origin: true (reflect any)      -> 204 | allow-headers: x-veryfront-control-plane-jws
origin: '*'                     -> 204 | allow-headers: x-veryfront-control-plane-jws
origin: 'https://evil.example'  -> 204 | allow-headers: x-veryfront-control-plane-jws

The header is attacker-settable. #3649 corrects both copies to state the real basis — the exemption skips only the browser-credential gate, authority comes from the downstream verification an attacker cannot forge, and every admitted route terminates at a handler doing that check ahead of ApiHandlerWrapper — and adds a forged-signature column plus a test pinning the CORS behaviour so the claim cannot be reinstated.

The corrections are in #3649 rather than here so both copies of the wording land together.

…cted

The CSRF exemption is granted on the dispatch header being *present*, so all
of its safety rests on `ChannelInvokeHandler` verifying the envelope behind
it. That downstream rejection had no end-to-end coverage: the full-chain test
only ever minted valid envelopes, and the malformed-signature cases in
`csrf-handler.test.ts` exercise `CsrfHandler` alone, never `RouteRegistry` or
the invoke handler.

Drives the real chain with a request that *takes* the exemption and must still
fail: `CsrfHandler` steps aside (so the answer is not 403), the handler fails
`verifyDispatchJws`, and the runtime answers 401 with the agent never reached.
Both failure modes are covered -- a value that is not a compact JWS, and a
well-formed envelope correctly bound to this body but signed by a key the
runtime does not trust.
@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 019c16b Aug 12, 2026
33 checks passed
@kojiwakayama
kojiwakayama deleted the fix/csrf-channels-invoke branch August 12, 2026 19:16
kojiwakayama added a commit that referenced this pull request Aug 12, 2026
…t just CSRF

#3647 exempted a signed channel dispatch from CSRF. On the merged tree the same
dispatch was still 401'd by a project's `security.auth` and still turned away
by a gating root `middleware.ts`, so the caller that fix set out to unblock was
still blocked twice, by the two gates nobody had reason to look at.

Apply `isSignedChannelDispatch` at those two sites as well. The two predicate
families stay separate on purpose: a channel dispatch carries a different
envelope under a different header, verified by `verifyDispatchJws` against the
dispatch id, platform, project id and body hash rather than by
`verifyControlPlaneJws` against a method and path. But the set of gates each is
exempt from must be the same, and that is now the thing under test.

Add the matrix: {control-plane dispatch, channel dispatch} x {security.auth,
security.csrf, project middleware.ts}, each cell asserting admitted when validly
signed, rejected at the gate when unsigned, and rejected downstream when forged.
The forged column carries the real security argument: the signature header is
attacker-settable, so what makes an exemption safe is that the route terminates
at a handler that verifies the envelope, never at project code. The file is the
artifact that stops the next gate from landing with a partial exemption.

The middleware guide gains the consequence a project author needs: root
middleware does not run for the project's own channel traffic either.
kojiwakayama added a commit that referenced this pull request Aug 12, 2026
#3641's docstring, repeated verbatim by #3647, argued the exemption was safe
because "a browser cannot attach the signature header to a cross-origin request
without a preflight the runtime does not grant". The runtime does grant it: with
no configured `allowedHeaders`, `resolveNormalizedCORSPreflightPolicy` reflects
whatever `Access-Control-Request-Headers` asked for, so any project whose CORS
policy admits an origin advertises the signature header to it. The proxy also
forwards an unverified `x-veryfront-*-jws` from a public request rather than
stripping it.

State the actual basis instead: the exemption skips only the browser-credential
gate, authority still comes from the downstream signature verification that an
attacker cannot forge, and every admitted route terminates at a handler doing
that verification ahead of ApiHandlerWrapper. Same correction to the three gate
comments that said the exemption is keyed on "the request being a real
dispatch": no predicate can know that from a header.
@kojiwakayama kojiwakayama mentioned this pull request Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant